'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api'; import { Avatar } from '@/components/ui/avatar'; import { AssetCard } from '@/components/ui/AssetCard'; import { FolderTree } from '@/components/folders/FolderTree'; import { ShareModal } from '@/components/share/ShareModal'; import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel'; import { useDropzone } from 'react-dropzone'; import { useUploadQueue } from '@/contexts/UploadQueueContext'; async function safeCopy(text: string): Promise { if (typeof window === 'undefined') return; try { const cb = navigator.clipboard; if (cb && typeof cb.writeText === 'function') { await cb.writeText(text); } else { const el = document.createElement('textarea'); el.value = text; el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0'; document.body.appendChild(el); el.focus(); el.select(); try { document.execCommand('copy'); } catch { /* ignore */ } document.body.removeChild(el); } } catch { /* ignore */ } } const ROLE_COLORS: Record = { ADMIN: 'badge-danger', EDITOR: 'badge-brand', REVIEWER:'badge-muted', VIEWER: 'badge-subtle', }; const ROLE_LABELS: Record = { ADMIN: 'Admin', EDITOR: 'Editor', REVIEWER:'Reviewer', VIEWER: 'Viewer', }; function formatGroupDate(d: Date): string { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today.getTime() - 86400000); const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()); if (videoDay.getTime() === today.getTime()) return 'Today'; if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday'; return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); } function groupByDay(assets: Asset[]): [string, Asset[]][] { const sorted = [...assets].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); const groups: Record = {}; for (const a of sorted) { const d = new Date(a.createdAt); const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString(); if (!groups[day]) groups[day] = []; groups[day].push(a); } return Object.entries(groups); } /** Collect asset IDs DIRECTLY in a folder (not from subfolders) */ function collectAssetIds(folders: FolderNode[], targetId: string | null): Set { const ids = new Set(); if (targetId === null) return ids; // "All Videos" — no filter function findTarget(f: FolderNode): FolderNode | null { if (f.id === targetId) return f; for (const c of f.children) { const r = findTarget(c); if (r) return r; } return null; } for (const f of folders) { const target = findTarget(f); if (target) { for (const id of target.assetIds) ids.add(id); break; } } return ids; } /** Get direct subfolders of a folder */ function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] { if (targetId === null) return folders; // root: show top-level folders function findTarget(f: FolderNode): FolderNode | null { if (f.id === targetId) return f; for (const c of f.children) { const r = findTarget(c); if (r) return r; } return null; } for (const f of folders) { const target = findTarget(f); if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)); } return []; } /** Build a map of assetId -> single deepest folder name */ function buildAssetFolders(allFolders: FolderNode[]): Map { const map = new Map(); const depthMap = new Map(); function search(f: FolderNode, depth: number): void { for (const id of f.assetIds) { const existingDepth = depthMap.get(id) ?? -1; if (depth > existingDepth) { map.set(id, f.name); depthMap.set(id, depth); } } for (const child of f.children) search(child, depth + 1); } for (const f of allFolders) search(f, 0); return map; } /** Get the folder name an asset belongs to (deepest only) */ function getAssetFolderNames(assetFolders: Map, assetId: string): string[] { const name = assetFolders.get(assetId); return name ? [name] : []; } /** Returns a breadcrumb path of folder names for the selected folder */ function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] { if (targetId === null) return []; const path: string[] = []; function search(f: FolderNode, trail: string[]): boolean { if (f.id === targetId) { path.push(...trail, f.name); return true; } for (const child of f.children) { if (search(child, [...trail, f.name])) return true; } return false; } for (const f of folders) if (search(f, [])) break; return path; } export default function ProjectDetailPage() { const params = useParams(); const projectId = params.projectId as string; const { user, token } = useAuth(); const router = useRouter(); const [project, setProject] = useState(null); const [members, setMembers] = useState([]); const [pendingInvites, setPendingInvites] = useState([]); const [assets, setAssets] = useState([]); const [folders, setFolders] = useState([]); const [allFolders, setAllFolders] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file'); const [loading, setLoading] = useState(true); const [sharingAssetId, setSharingAssetId] = useState(null); const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos'); // Invite form state (single shared form) const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState('REVIEWER'); const [inviting, setInviting] = useState(false); const [inviteError, setInviteError] = useState(''); const [inviteSuccess, setInviteSuccess] = useState(''); const [createdLink, setCreatedLink] = useState(''); const [createdLinkEmail, setCreatedLinkEmail] = useState(''); const [linkCopiedAgain, setLinkCopiedAgain] = useState(false); // Edit member role const [editingRoleId, setEditingRoleId] = useState(null); const [editingRole, setEditingRole] = useState(''); const [updatingRole, setUpdatingRole] = useState(false); // Remove member const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null); const [removing, setRemoving] = useState(false); // Revoke invite const [revokingId, setRevokingId] = useState(null); // Copy link const [copiedInviteId, setCopiedInviteId] = useState(null); const [inviteUrlMap, setInviteUrlMap] = useState>({}); const [reprocessingAll, setReprocessingAll] = useState(false); const [globalStuckCount, setGlobalStuckCount] = useState(0); const canManage = members.some(m => m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role) ); const isOwner = project?.ownerId === user?.id; const isAdmin = user?.globalRole === 'ADMIN'; // Poll workspace-wide stuck job count every 30s (admins + editors can use it) useEffect(() => { if ((!isAdmin && !canManage) || !token) return; let cancelled = false; async function fetchStuckCount() { const t = token as string; try { const data = await assetsApi.getStuckCount(t, projectId as string); if (!cancelled) setGlobalStuckCount(data.count ?? 0); } catch {} } fetchStuckCount(); const id = setInterval(fetchStuckCount, 30_000); return () => { cancelled = true; clearInterval(id); }; }, [isAdmin, canManage, token]); // ── Folder data derived from state ────────────────────────────────────────── // For file mode: only assets directly in the selected folder const folderAssetIds = assets.length > 0 ? collectAssetIds(folders, selectedFolderId) : new Set(); // For timeline mode: assets in selected folder AND all its subfolders const timelineAssetIds = (() => { const ids = new Set(); if (selectedFolderId === null) return ids; function findTarget(f: FolderNode): FolderNode | null { if (f.id === selectedFolderId) return f; for (const c of f.children) { const r = findTarget(c); if (r) return r; } return null; } function collectAll(f: FolderNode): void { for (const id of f.assetIds) ids.add(id); for (const c of f.children) collectAll(c); } for (const f of folders) { const target = findTarget(f); if (target) { collectAll(target); break; } } return ids; })(); const filteredAssets = selectedFolderId === null ? assets : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []); // Timeline uses all assets in the selected folder AND its subfolders const timelineAssets = selectedFolderId === null ? assets : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []); const subfolders = getSubfolders(folders, selectedFolderId); const breadcrumb = getBreadcrumb(folders, selectedFolderId); const assetFolders = buildAssetFolders(allFolders); // ── Delete project ────────────────────────────────────────────────────────── const [confirmDeleteProject, setConfirmDeleteProject] = useState(false); const [deletingProject, setDeletingProject] = useState(false); const handleDeleteProject = async () => { if (!token) return; setDeletingProject(true); try { await projectsApi.delete(token, projectId); router.push('/projects'); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete project'); } finally { setDeletingProject(false); setConfirmDeleteProject(false); } }; const loadFolders = useCallback(async () => { if (!token) return; try { const data = await foldersApi.list(token, projectId); setFolders(data.folders); setAllFolders(data.allFolders); } catch (e) { console.error('Failed to load folders:', e); } }, [token, projectId]); const loadAll = useCallback(async () => { if (!token) return; try { const [{ project: p }, { assets: a }] = await Promise.all([ projectsApi.get(token, projectId), assetsApi.list(token, projectId), ]); setProject(p); setMembers(p.members ?? []); setAssets(a); if (canManage) { const { invitations } = await invitationsApi.list(token, projectId); setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING')); } } catch { router.push('/projects'); } finally { setLoading(false); } }, [token, projectId, router, canManage]); useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]); // ── Invite member ────────────────────────────────────────────────────────── const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!token || !inviteEmail.trim()) return; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) { setInviteError('Invalid email address'); return; } setInviting(true); setInviteError(''); setInviteSuccess(''); setCreatedLink(''); try { const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole); const { invitations } = await invitationsApi.list(token, projectId); setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING')); setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl })); setInviteEmail(''); setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`); setTimeout(() => setInviteSuccess(''), 3000); } catch (err) { setInviteError(err instanceof Error ? err.message : 'Failed to send invitation'); } finally { setInviting(false); } }; // ── Create & copy link ───────────────────────────────────────────────────── const handleCreateLink = async () => { if (!token || !inviteEmail.trim()) return; setInviting(true); setInviteError(''); setInviteSuccess(''); setCreatedLink(''); setLinkCopiedAgain(false); const email = inviteEmail.trim(); try { const { inviteUrl } = await invitationsApi.create(token, projectId, email, inviteRole); const { invitations } = await invitationsApi.list(token, projectId); setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING')); await safeCopy(inviteUrl); setCreatedLink(inviteUrl); setCreatedLinkEmail(email); setInviteEmail(''); } catch (err: any) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) { setInviteError(`An invitation for "${email}" is already pending or the user is already a member.`); } else { setInviteError(msg || 'Failed to create invitation link'); } } finally { setInviting(false); } }; // ── Change role ──────────────────────────────────────────────────────────── const handleChangeRole = async (memberId: string) => { if (!token || !editingRole) return; setUpdatingRole(true); try { await projectsApi.updateMember(token, projectId, memberId, editingRole); setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m)); setEditingRoleId(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to update role'); } finally { setUpdatingRole(false); } }; // ── Remove member ───────────────────────────────────────────────────────── const handleRemoveMember = async () => { if (!token || !confirmRemove) return; setRemoving(true); try { await projectsApi.removeMember(token, projectId, confirmRemove.id); setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id)); setConfirmRemove(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to remove member'); } finally { setRemoving(false); } }; // ── Revoke invite ────────────────────────────────────────────────────────── const handleRevoke = async (invitationId: string) => { if (!token) return; setRevokingId(invitationId); try { await invitationsApi.revoke(token, invitationId); setPendingInvites(prev => prev.filter(i => i.id !== invitationId)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to revoke invitation'); } finally { setRevokingId(null); } }; // ── Copy invite link ────────────────────────────────────────────────────── const handleCopyLink = async (invite: Invitation) => { const base = window.location.origin; const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`; await safeCopy(url); setCopiedInviteId(invite.id); setTimeout(() => setCopiedInviteId(null), 2000); }; // ── Upload ───────────────────────────────────────────────────────────────── const { enqueue, totalActive } = useUploadQueue(); const handleDrop = (acceptedFiles: File[]) => { if (!token || acceptedFiles.length === 0) return; for (const file of acceptedFiles) { enqueue({ projectId, folderId: selectedFolderId ?? undefined, file, }); } }; const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({ onDrop: handleDrop, accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] }, multiple: true, disabled: totalActive > 0, }); // Poll for assets that are still processing const pollingRef = useRef | null>(null); // ── Delete asset ───────────────────────────────────────────────────────── const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null); const [deletingId, setDeletingId] = useState(null); const handleDeleteAsset = (id: string, title: string) => { setConfirmDelete({ id, title }); }; // ── Remove asset from a folder ────────────────────────────────────────── const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => { if (!token) return; // Find the folder by name within the project const folder = allFolders.find(f => f.name === folderName); if (!folder) return; try { await foldersApi.removeAsset(token, folder.id, assetId); // Refresh folder data so asset disappears from the folder loadFolders(); } catch (err) { console.error('Failed to remove from folder:', err); } }, [token, allFolders, loadFolders]); const confirmDeleteAsset = async () => { if (!token || !confirmDelete) return; setDeletingId(confirmDelete.id); try { await assetsApi.delete(token, confirmDelete.id); setAssets(prev => prev.filter(a => a.id !== confirmDelete.id)); setConfirmDelete(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete video'); } finally { setDeletingId(null); } }; useEffect(() => { const processingAssets = assets.filter(a => ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus) ); if (processingAssets.length === 0) { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } return; } if (pollingRef.current) return; pollingRef.current = setInterval(async () => { if (!token) return; try { const { assets: updated } = await assetsApi.list(token, projectId); setAssets(updated); } catch {} }, 3000); return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; }, [token, projectId, assets]); if (loading) { return (
Loading…
); } return (
{/* Full-page upload overlay when dragging files */} {isUploadDragActive && (

Drop videos to upload

MP4, MOV, WebM — up to 500MB each

)} {/* Header */}

{project?.name}

{canManage && ( {isAdmin ? 'Owner' : 'Editor'} )} {!canManage && !isAdmin && ( {members.find(m => m.user.id === user?.id)?.role ?? 'Member'} )}
{project?.description && (

{project.description}

)}
{/* Upload button — compact, in header */} {canManage && ( )} {/* Tabs */}
{[ { tab: 'videos', label: 'Videos', count: assets.length }, { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length }, { tab: 'members', label: 'Members', count: members.length }, ].map(({ tab, label, count }) => ( ))}
{/* Delete project — owner only */} {isOwner && ( )}
{/* ── Videos Tab ───────────────────────────────────────────────────── */} {activeTab === 'videos' && ( <> {/* File/Timeline mode toggle + breadcrumb bar */} {activeTab === 'videos' && (
{/* Breadcrumb */}
{/* Asset count */} {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''} {/* Mode toggle */}
{[ { mode: 'file' as const, label: 'File', icon: ( )}, { mode: 'timeline' as const, label: 'Timeline', icon: ( )}, ].map(({ mode, label, icon }) => ( ))}
)}
{/* Left panel: FolderTree (both file and timeline modes) */} {/* Main content */}
{/* Upload zone for non-managers */} {!canManage && (

Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.

)} {/* File mode content */} {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (

{selectedFolderId ? 'No videos in this folder' : 'No videos yet'}

{selectedFolderId ? 'Drag videos here or move them from other folders' : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}

) : viewMode === 'file' ? ( // File mode: subfolders + videos
{/* Subfolders */} {subfolders.length > 0 && (
Folders
{subfolders.map(folder => ( ))}
)} {/* Videos in this folder */} {filteredAssets.length > 0 && (
{filteredAssets.map((asset, i) => ( router.push(`/review/${asset.id}`)} onDelete={() => handleDeleteAsset(asset.id, asset.title)} onCancel={async (id) => { if (!token) return; try { await assetsApi.cancelTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); } }} onPause={async (id) => { if (!token) return; try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); } }} onResume={async (id) => { if (!token) return; try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); } }} animationDelay={i * 40} folderNames={getAssetFolderNames(assetFolders, asset.id)} onShare={setSharingAssetId} isShared={!!asset.isShared} onRemoveFromFolder={handleRemoveFromFolder} /> ))}
)}
) : ( // Timeline mode: grouped by date
{groupByDay(timelineAssets).map(([dayKey, dayAssets]) => { const groupDate = new Date(dayKey); const showHour = dayAssets.length > 1; return (
{formatGroupDate(groupDate)}
{dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
{dayAssets.map((asset, i) => { const createdAt = new Date(asset.createdAt); return (
router.push(`/review/${asset.id}`)} draggable={canManage} onDragStart={canManage ? (e) => { e.dataTransfer.setData('assetId', asset.id); e.dataTransfer.setData('text/plain', asset.title); e.dataTransfer.effectAllowed = 'move'; if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') { const ghost = document.createElement('div'); ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;'; const img = document.createElement('img'); img.src = `/uploads/${asset.thumbnail}`; img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;'; const label = document.createElement('span'); label.textContent = asset.title; label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;'; ghost.appendChild(img); ghost.appendChild(label); document.body.appendChild(ghost); e.dataTransfer.setDragImage(ghost, 30, 28); setTimeout(() => document.body.removeChild(ghost), 0); } } : undefined} > {/* Thumbnail */}
{asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? ( {asset.title} ) : (
)}
{/* Info */}

{asset.title}

{asset.duration && ( {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`} )}
{/* Folder tags */} {(() => { const tags = getAssetFolderNames(assetFolders, asset.id); return tags.length > 0 ? (
{tags.map((name, i) => ( {name} ))}
) : null; })()}
{asset.uploader?.name ?? 'Unknown'} · {showHour ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} · {(asset as any)._count?.comments ?? 0} comments
{/* Play button */}
); })}
); })}
)}
)} {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */} {activeTab === 'transcode' && (
{ if (!token) return; try { await assetsApi.cancelTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); } }} onPause={async (id) => { if (!token) return; try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); } }} onResume={async (id) => { if (!token) return; try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); } }} onReprocess={async (id) => { if (!token) return; try { await assetsApi.cancelTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reprocess transcode'); } }} onReprocessAll={async () => { if (!token) return; setReprocessingAll(true); try { const result = await assetsApi.reprocessAll(token, projectId as string); // Reset all PROCESSING assets in local state setAssets(prev => prev.map(a => a.transcodeStatus === 'PROCESSING' ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0 } : a )); alert(result.message); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reset stuck jobs'); } finally { setReprocessingAll(false); } }} isReprocessingAll={reprocessingAll} globalStuckCount={globalStuckCount} />
)} {/* ── Members Tab ─────────────────────────────────────────────────── */} {activeTab === 'members' && (
{/* Invite form */} {canManage && (

Invite someone

{ e.preventDefault(); handleInvite(e); }} className="flex items-end gap-3 flex-wrap" >
setInviteEmail(e.target.value)} placeholder="colleague@company.com" />
{createdLink && (
Invitation link created!

Invite sent to {createdLinkEmail} as {inviteRole} · Link expires in 7 days

{createdLink}

Share this link with your colleague — they can use it to join the project directly.

)} {inviteError &&

{inviteError}

} {inviteSuccess &&

{inviteSuccess}

}
)} {/* Members list */}

Members ({members.length})

{members.length === 0 ? (

No members yet

) : (
{members.map(m => { const isMe = m.user.id === user?.id; const canEdit = isAdmin && !isMe; return (
{m.user.name} {isMe && (you)}

{m.user.email}

{new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} {editingRoleId === m.id ? (
) : (
{ROLE_LABELS[m.role] ?? m.role} {canEdit && ( )} {isAdmin && !isMe && ( )}
)}
); })}
)}
{/* Pending invitations */} {canManage && (

Pending invitations

{pendingInvites.length}
{pendingInvites.length === 0 ? (

No pending invitations

) : (
{pendingInvites.map(inv => (
{inv.email} {ROLE_LABELS[inv.role] ?? inv.role}
Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}
))}
)} {pendingInvites.length > 0 && (

Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.

)}
)}
)}
{/* Share modal */} {sharingAssetId && ( setSharingAssetId(null)} /> )} {/* Delete asset confirm modal */} {confirmDelete && (

Delete video?

"{confirmDelete.title}"

This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.

)} {/* Remove member confirm modal */} {confirmRemove && (

Remove {confirmRemove.name}?

They'll lose access to this project and all its videos. They can rejoin if invited again.

)} {/* Delete project confirm modal */} {confirmDeleteProject && (

Delete "{project?.name}"?

{assets.length} video{assets.length !== 1 ? 's' : ''} will be permanently deleted

This will permanently delete the project, all videos, comments, and assets. This action cannot be undone.

)}
); }